iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
JavaScript

重新拿回思考力!一起來用 JavaScript 打造 CLI 好玩遊戲區!系列 第 7

Day7 推箱子6 :先窮舉後精簡的練習

  • 分享至 

  • xImage
  •  

今天我們要秉持著打造原型的精神,試著來完成「兩個箱子一個目標點」的玩法,不過由於兩個箱子的做法相較於一個箱子會更複雜一些,所以我想跟大家一起練習「先窮舉後精簡」,也就是說我們先盡可能把所有想到的走法都走過一遍,再來整理有哪些共用的架構、該如何精簡。

不過,開始前一樣用自己的話來描述我要做的事情,先來拆解目標:

  1. 讓畫面出現兩個不重複的箱子
  2. 改變規則,從原本的玩家站到按鈕上就將箱子推出邊緣,改成讓任意一個箱子放到按鈕上,就將另一個箱子推出邊緣
  3. 任意一個箱子移動到目標點上,遊戲就獲勝

我們也可以預先想想可能會發生的問題,就可以事先在程式內去做避免:

  1. 生成時兩個箱子不能佔據同一格
  2. 假設兩個箱子位於相鄰的兩格,從相鄰的第三格推動箱子的話,就可以同時移動兩個箱子

那我們就依序開始實作吧!

生成兩個不同的箱子

首先一定會先制定變數,接著我們會遇到需要修改 boxPicker 的問題:如何讓兩個箱子生成時不出現在同一格,且維持原本的生成規則?

我目前想到的做法就是在 boxPicker 內加上要迴避的座標,並且把這個座標帶入到 pickRandomEmptyCell 裡面

// 首先把要避開的座標都一起傳進來
function boxPicker(map,occupied = []) {
    // 傳給 pickRandomEmptyCell 的變數改用傳進來的值
    let box = pickRandomEmptyCell(map, occupied);
    // 這段是確認這個箱子是否無解的邏輯,不動
    const { top,bottom,left,right,isDeadlock } = checkBox (map,box)
    if (isDeadlock || (top && box.y !== goal.y ) || (bottom && box.y !== goal.y ) ||  (left && box.x !== goal.x) ||  (right && box.x !== goal.x)) {
        return boxPicker(map,occupied);
    }

    // 一樣傳回這個箱子的座標
    return box;

}

接著我們要來修改 box 的變數,新增為兩個

let box1 = boxPicker(map,[player,goal]); // box 改為 box1,要避開項目的不變
let box2 = boxPicker(map,[player,goal,box1]); // 新增一個 box2 ,避開的項目加上 box1
let button = pickRandomEmptyCell(map, [player, box1 , box2, goal]); //button 要避開的項目從 box 改為 box1 和 box2

然後來檢查一下其他的位置還有沒有殘留的 box 變數,一一來處理。

首先是 render 裡面的這一段印出物件的邏輯,需要加上第二個箱子:

            if (player.x === x && player.y === y) {
                row += `\x1b[33m@\x1b[0m`
                // box 改為 box1
            } else if (box1.x === x && box1.y === y) {
                row += `\x1b[34m$\x1b[0m`
                // 加上 box2 ,一樣要印出 $ 代表箱子
            } else if (box2.x === x && box2.y === y) {
                row += `\x1b[34m$\x1b[0m`
            } else if (goal.x === x && goal.y === y) {
                row += `\x1b[31m.\x1b[0m`
            } else if (button.x === x && button.y === y) {
                row += `\x1b[31mx\x1b[0m`
            } else if (cell === '@' || cell === '$' || cell === '.') {
                row += ' '
            } else {
                row += `\x1b[32m${cell}\x1b[0m`
            }

移動規則訂定

接著要來處理 process.stdin.on 監聽器的內部上下左右的邏輯,這邊先以向上移動為例,先觀察目前程式碼寫的是按下方向鍵上之後,如果箱子在玩家的上方且更上方不是牆壁的話,則玩家和箱子都向上移動一格,我們要加上兩個箱子的邏輯:

        // 如果 box 存在於玩家的上方這一排,且 x 軸一樣
        if (player.y - 1 === box.y && player.x === box.x) {
            // 更上方如果不是牆壁的話,兩個都往上一格
            if (map[player.y - 2][player.x] !== '#') {
                player.y -= 1
                box.y -= 1
            }
        // 玩家的上方不是牆壁的話
        } else if (map[player.y - 1][player.x] !== '#') {
            player.y -= 1
        }

窮舉

接著我們就來練習一下要如何窮舉吧!一樣我會用問題來一個一個問自己:

Q.首先只列出第一個條件的所有可能選項:player 和 box 是否在同一個 x 軸上面呢?

A.全部在同一個 x 軸、只有和其中一個在同一個 x 軸、完全都不在同一個 x 軸

Q.那麼下一個問題就是假設全部都在同一個 x 軸,有哪些情況呢?

A.player 的上方沒有東西、player 的上方只有一個 box、player 的上方有連續的兩個 box

Q.再假設 player 的上方沒有東西的話,他可以直接向上移動嗎?

A.要判斷有沒有牆壁,如果沒有牆壁就可以向上移動。

整理

回答完了之後可以整理出這樣的關係,我把第一個問題的答案拿過來作為第一層的分類,再往下逐步展開第二層、第三層:

  1. player 的 x 和兩個 box 的 x 都不一樣
    • 檢查 player 上方是否是牆壁,不是的話 player 可以直接向上移動一格
  2. player 的 x 只有和其中一個 box 的 x 一樣
    • box 位於 player 的上方一格
      • 檢查該 box 上方是否是牆壁,不是的話 player 和該 box 都向上移動一格
    • box 不在 player 的上方一格
      • 檢查 player 上方是否是牆壁,不是的話 player 可以直接向上移動一格
  3. player 的 x 和兩個 box 的 x 都一樣的話
    • player 的 y-1 屬於其中一個 box ,且 y-2 屬於另外一個 box
      • 檢查 y-3 是不是牆壁,不是的話,三個都向上移動一格
    • player 的 y-1 屬於其中一個 box,但 y-2 不屬於另外一個 box
      • 檢查 y-2 是不是牆壁,不是的話,player 和該 box 向上移動一格
    • player 的 y-1 不屬於任何一個 box
      • y-1 也不是牆壁,player 向上移動一格

那這樣我們就把向上的方向走完了,有點像探索迷宮一樣!!這就是窮舉的方法,當我們窮舉列出所有可能的路線之後,可以試著撰寫程式碼。

移動邏輯

寫到這邊,有沒有注意到已經跟原本的邏輯差異很大了,所以我們忘記原本的邏輯,試著重新撰寫一遍

首先來寫第一組分類,為了避免影響到原本的遊戲邏輯,我們先不直接去寫 process.stdin.on,我們在檔案最下方加上這一段邏輯,以便在遊戲一開啟時就可以觀察邏輯是否正確

if (player.x === box1.x && player.x === box2.x) {
     console.log('全部在同一個 x 軸上')
} else if (player.x === box1.x || player.x === box2.x) {
     console.log('只有一個在同一條 x 軸上')
} else if (player.x !== box1.x && player.x !== box2.x) {
     console.log('都不在同一個 x 軸')
} else {
     console.log('出錯了')
}

https://ithelp.ithome.com.tw/upload/images/20260919/20182439jxRVowIaEL.png

驗證了邏輯正確之後我們接著往下寫第二層判斷,試著寫完都在同一個 x 軸上的邏輯

if (player.x === box1.x && player.x === box2.x) {
    console.log('全部在同一個 x 軸上')

    if (( player.y-1 === box1.y || player.y-1 === box2.y ) && ( player.y-2 === box1.y || player.y-2 === box2.y )){
        console.log('三個連成一排')
    }else if ( player.y-1 === box1.y || player.y-1 === box2.y ){
        console.log('只有一個在上面')
    }else if ( player.y-1 !== box1.y && player.y-1 !== box2.y ){
        console.log('都不在上面')
    }

}// 其他的部分先不動

為了驗證方便,我們也先把角色座標改成自己寫的座標

// let player = pickRandomEmptyCell(map); // 用來儲存玩家位置
// let goal = pickRandomEmptyCell(map, [player]); // 用來儲存目標點位置
// let box1 = boxPicker([player, goal]); // 用來儲存箱子位置
// let box2 = boxPicker([player, goal, box1]);
// let button = pickRandomEmptyCell(map, [player, box1, box2, goal]); // 用來儲存按鈕位置
let player = {x:3,y:3};
let goal = {x:2,y:3};
let box1 = {x:3,y:2};
let box2 = {x:3,y:1};
let button = {x:5,y:2};

小提醒:避免執行出錯的關係可以先把所有 box 的舊邏輯先註解掉

https://ithelp.ithome.com.tw/upload/images/20260919/20182439OPaWyMQFy2.png

再接著寫第三層的邏輯

//  在全部都在同一個 x 軸上的判斷中
if (( player.y-1 === box1.y || player.y-1 === box2.y ) && ( player.y-2 === box1.y || player.y-2 === box2.y )){
        console.log('三個連成一排')

        if(map[player.y - 3][player.x] !== '#') {
            console.log('三個都要向上一步')
        }else {
            console.log('無法移動')
        }
    }

https://ithelp.ithome.com.tw/upload/images/20260919/20182439AYyUiMaetA.png

後面就給大家自行練習了!! 如果說還是覺得很困難的話,也可以從 console.log先寫,就會知道這一段應該要判斷什麼內容

也可以試著思考如何把重複撰寫的邏輯抽成函式來共用

最後提供寫完的版本給大家參考,別忘了把 console.log 換成最終的行動,並把這一段放到 process.stdin.on 監聽器的 if (key === '\x1b[A') 裡面,作為按下上方向鍵之後要做的事情,試試看用上方向鍵可不可以移動成功

if (key === '\x1b[A') {

        if (player.x === box1.x && player.x === box2.x) {

        if ((player.y - 1 === box1.y || player.y - 1 === box2.y) && (player.y - 2 === box1.y || player.y - 2 === box2.y)) {

            if (map[player.y - 3][player.x] === ' ') {
                player.y -= 1;
                box1.y -=1;
                box2.y -=1;
            }

        } else if (player.y - 1 === box1.y || player.y - 1 === box2.y) {

            if (player.y - 1 === box1.y && map[player.y - 2][player.x] === ' ') {
                player.y -=1;
                box1.y -=1;
            } else if (player.y - 1 === box2.y && map[player.y - 2][player.x] === ' ') {
                player.y -=1;
                box2.y -=1;
            }

        } else if (player.y - 1 !== box1.y && player.y - 1 !== box2.y) {

            if (map[player.y - 1][player.x] === ' ') {
                player.y -=1;
            }
        }

    } else if (player.x === box1.x) {

        if (player.y - 1 === box1.y && map[player.y - 2][player.x] === ' ') {
            player.y -=1;
            box1.y -=1;
        } else if (map[player.y - 1][player.x] === ' ') {
            player.y -=1;
        }
    } else if (player.x === box2.x) {

        if (player.y - 1 === box2.y && map[player.y - 2][player.x] === ' ') {
            player.y -=1;
            box2.y -=1;
        } else if (map[player.y - 1][player.x] === ' ') {
            player.y -=1;
        }
    } else if (player.x !== box1.x && player.x !== box2.x) {

        if (map[player.y - 1][player.x] === ' ') {
            player.y -=1;
        }

    }
    }

但是如果每個方向都要這麼寫就太累了,所以我在思考要如何精簡的時候,想到如果把它抽成共用的函式就可以在四個方向都去套用這個函式,這個時候因為我們其實已經了解邏輯怎麼做了,可是卻對於如何精簡苦思許久,所以我決定請 AI 產出可以運用於四個方向的共用函式就好,不要讓我們為了苦苦設計如何精簡邏輯而卡在這裡

一起來看一下 AI 產出的函式,我們可以試著用自己的語言補上註解,同時也檢查符不符合我們剛剛列出的邏輯:

function movePlayer(dx, dy) { 
      // 藉由傳入的 dx 和 dy 來定位下一個座標,這樣我們就知道要往哪邊走
      const nextX = player.x + dx; // 下一個 x  
      const nextY = player.y + dy; // 下一個 y 
   
      // 兩個 box 寫成陣列
      const boxes = [box1, box2];

      // 找到是誰位於下一個位置,記錄下來(這邊用傳參考的方式,所以修改 firstBox 也會修改到原始值)
      const firstBox = boxes.find(box => box.x === nextX && box.y === nextY);

      // 前面沒有箱子
      if (!firstBox) {
          // 如果前方也是空的
          if (map[nextY][nextX] === ' ') {
              player.x = nextX; // player 的座標移動到下一個位置
              player.y = nextY;
          }
          return; // 截斷函式不往下跑
      }
      
      // 如果前面有箱子的話就會往下走
      // 紀錄箱子的再下一個位置
      const boxNextX = firstBox.x + dx;
      const boxNextY = firstBox.y + dy;

      // 判斷有沒有下一個箱子
      const secondBox = boxes.find(
          box =>
              box !== firstBox &&  // 如果這個箱子不是第一個箱子,而且位置剛好在第一個箱子的下一個位置
              box.x === boxNextX &&
              box.y === boxNextY
      );

      // 前面有兩個箱子,要確認第二個箱子後面是空的
      if (secondBox) {
          // 紀錄第二個箱子的再下一個位置
          const afterSecondBoxX = secondBox.x + dx;
          const afterSecondBoxY = secondBox.y + dy;

          // 確認這個位置是不是空的,是的話三個都往後一格
          if (map[afterSecondBoxY][afterSecondBoxX] === ' ') {
              player.x = nextX;
              player.y = nextY;

              firstBox.x += dx;
              firstBox.y += dy;

              secondBox.x += dx;
              secondBox.y += dy;
          }

          return;
      }

      // 前面只有一個箱子,要確認箱子後面是空的
      if (map[boxNextY][boxNextX] === ' ') {
          // player 和前面的箱子都往前一格
          player.x = nextX;
          player.y = nextY;

          firstBox.x = boxNextX;
          firstBox.y = boxNextY;
      }
  }
// 監聽器內四個方向換上這個邏輯
if (key === '\x1b[A') {
        movePlayer(0, -1)
    }
    if (key === '\x1b[B') {

        movePlayer(0, 1)
    }
    if (key === '\x1b[C') {

        movePlayer(1, 0)
    }
    if (key === '\x1b[D') {
        movePlayer(-1, 0)
    }

是不是覺得很神奇呢,我們剛剛列出了那麼多複雜的步驟,結果他改成判斷前方有沒有箱子、再判斷更前方有沒有箱子、能不能往前走,然後就結束了這一題。不過也不需要擔心說利用 AI 是不是就沒有練習到,回顧今天我們做的練習,我們從窮舉開始列出每一個可能性,接著做了一點小整理,然後動手實作,最後我們決定要做精簡,而且也有明確的目的要讓 AI 寫一個函式來取代這一大段的邏輯,對於「練習思考」的這個目標,我認為已經達到了,既然達到目的,我們就要學習在適當的時機不讓自己卡住、糾結下去,這件事情我在後面的章節還會再繼續地去做討論。

總而言之,到這邊我們已經可以來嘗試執行 node index.js 來看看是否可以正確地移動箱子吧!

雙箱子的移動

現在已經可以成功移動箱子了!不過你以為這樣就結束了嗎?還沒有!!!往下看,其實還有死亡的判定、勝負判定、將箱子推到按鈕以彈出箱子,這些邏輯都還沒有改成雙箱子的邏輯,我想不妨大家自己先來嘗試看看,明天我們再一起來討論吧!

如果你想要看完整的程式碼,可以參考這裡:Day7 程式碼


上一篇
Day6 推箱子5:不知道想法好不好,就打造原型!
系列文
重新拿回思考力!一起來用 JavaScript 打造 CLI 好玩遊戲區!7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言